#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" Defines a simple url dispatcher.

The client of this module defines a list of records in the form:

    (URL REGEX, REQUEST METHOD REGEX, CALLABLE)

For example, like this:

    urls_v1 = [
        ('/eggs/version2/', 'GET|POST', some.broke.module.callable),
        ('/eggs/version3/', 'GET|POST', some.nifty.module.callable),
    ]


When the dispatcher matches both the url regex and the method regex, the
corresponding  application is called.

OR

    urls_v2 = [
    ('/ham/v3/',
     ('',
      {'GET':empty}
      ),
    ),
    ('/ham/v4/',
     ('',
      {'GET':empty,
       'POST':empty}
     )
    ),
    ('/ham/v5/',
     ('(?P<key>\d+)/',
            {'GET':empty}
     ),
    )
    ]

    (See tests/dispatcher.txt for full available syntax)

When the URL and method (and optional regex and query string) match, the
corresponding application is called.

The dispatcher app returned depends on the format of the data structure passed
to dispatcher().

"""
from logging import debug
from re import match, I, compile as rc
from tack.lib import status_codes, content_headers, not_allowed

class V2URLS(object):

    class V2URLSException(Exception):
        pass

    class MalformedURLList(V2URLSException):
        pass

    class MalformedMatchBlock(V2URLSException):
        pass

    class MalformedActionBlock(V2URLSException):
        pass

    class MethodMismatch(V2URLSException):
        pass

    class NotFound(V2URLSException):
        pass

    @classmethod
    def parse_qs(cls, qs):
        """
        Parses out a query string, returning a list of lists.
        """
        return [p.split('=') if '=' in p else [p, ''] for p in qs.split('&')]

    class URLHead(object):
        def __init__(self, path, matches):
            self.matches = []
            self.blank = None
            if type(path) != str:
                raise V2URLS.MalformedURLList('Invalid path: %s' % str(path))
            else:
                self.path = path

            for m in matches:
                if type(m) != tuple:
                    raise V2URLS.MalformedURLList('Matches for %s are '
                                                  'malformed' % path)
                else:
                    try:
                        match = V2URLS.URLMatch(m[0], m[1])
                        if m[0] == '':
                            self.blank = match
                        else:
                            self.matches.append(match)
                    except V2URLS.MalformedMatchBlock, e:
                        raise V2URLS.MalformedMatchBlock('Path %s had a malformed match '
                                                         'block for regex %s: %s' %
                                                         (path, m[0], str(e)))
                    except V2URLS.MalformedActionBlock, e:
                        raise V2URLS.MalformedActionBlock('Path %s had a malformed action '
                                                          'block: %s' % (path, str(e)))
        
        def match_regex(self, meth, data, qs):
            groups = ()
            for m in self.matches:
                data_match = m.regex.match(data)
                if data_match:
                    if hasattr(data_match, 'groupdict') and hasattr(data_match, 'groups'):
                        groups = data_match.groupdict() or data_match.groups()

                        return m.method_match(meth, qs) + (groups,)

            # If we've gotten this far, we haven't matched
            if self.blank:
                return self.blank.method_match(meth, qs) + (groups,)
            else:
                raise V2URLS.NotFound

    class URLMatch(object):
        def __init__(self, regex, methods):
            self.regex = rc(''.join(['^', regex, '$']), I)
            self.get = self.post = self.put = self.delete = None

            if type(methods) != dict:
                raise V2URLS.MalformedMatchBlock('type %s instead of a dict' % 
                                                 str(type(methods)))
            try:
                for m,d in methods.items():
                    setattr(self, m.lower(), V2URLS.URLAction(d))
            except V2URLS.MalformedActionBlock, e:
                raise V2URLS.MalformedActionBlock('method %s %s' % (m, str(e)))

        def method_match(self, meth, qs):
            action = getattr(self, meth.lower())

            if not action:
                raise V2URLS.MethodMismatch

            return action.qs_match(qs)

    class URLAction(object):
        def __init__(self, action):
            self.call = None
            self.qs_matches = []
            self.qs_blank = None
            self.qs_default = None
            self.extra_environ = {}

            if callable(action):
                self.call = action

            elif type(action) == tuple:
                self.call = action[0]
                self.extra_environ = action[1]

            elif type(action) == list:
                for m in action:
                    if type(m) != tuple:
                        raise V2URLS.MalformedActionBlock('was not a list of tuples')
                    else:
                        if m[0] == '*':
                            self.qs_default = V2URLS.URLAction(m[1])
                        elif m[0] == '':
                            self.qs_blank = V2URLS.URLAction(m[1])
                        else:
                            self.qs_matches.append((V2URLS.parse_qs(m[0]),
                                V2URLS.URLAction(m[1])))
            else:
                raise V2URLS.MalformedActionBlock('was not a callable, tuple, or list')

        def qs_match(self, qs):
            if self.call:
                return self.call, self.extra_environ
            if qs == '':
                if self.qs_blank:
                    return self.qs_blank.call, self.qs_blank.extra_environ
                else:
                    raise V2URLS.NotFound
            else: # We need to match on the query string
                parsed_qs = dict(V2URLS.parse_qs(qs))

                for match in self.qs_matches:
                    matches = []
                    for key, value in match[0]:
                        if key in parsed_qs:
                            if len(value) > 0 and parsed_qs[key] == value:
                                matches.append(True)
                            elif len(value) == 0:
                                matches.append(True)
                            else:
                                matches.append(False)
                        else:
                            matches.append(False)
                    if all(matches):
                        return match[1].call, match[1].extra_environ

                # If we're here, we never matched
                if self.qs_default:
                    return self.qs_default.call, self.qs_default.extra_environ
                else:
                    raise V2URLS.NotFound

    def __init__(self, urls):
        self.urls = []
        for r in urls:
            self.urls.append(V2URLS.URLHead(r[0], r[1:]))

    def match_request(self, meth, path, qs):
        for url in self.urls:
            if path.startswith(url.path):
                data = path.replace(url.path, '', 1)
                return url.match_regex(meth, data, qs)
        raise V2URLS.NotFound


def dispatcher(urls, handle404, redirect_with_script_name=False,
               handle405=not_allowed, group_key='dispatcher.groups'):
    """ Creates new dispatcher for given urls.

    @param urls sequence of url records
    @param handle404 application to call when no url matched
    @keyparam group_key='dispatcher.groups' key for regex matches
    @return new dispatcher WSGI application
    """
    urls = list(urls)
    count = len(urls)
    debugfs = 'dispatcher match [%s/%s]: %s to %s'

    def dispatcher_v1(environ, start_response):
        path = environ['PATH_INFO']
        meth = environ['REQUEST_METHOD']
        meth_mismatch = False

        for idx, (url_pat, meth_pat, app) in enumerate(urls):
            if '(' in url_pat:
                url_pat = '^%s$' % url_pat
                path_match, meth_match = match(url_pat, path), meth.upper() in meth_pat
            else:
                path_match, meth_match = (url_pat == path), meth.upper() in meth_pat
            if path_match and meth_match:
                debug(debugfs, idx, count, url_pat, app)
                environ['dispatcher.urls'] = urls
                if hasattr(path_match, 'groupdict') and hasattr(path_match, 'groups'):
                    environ[group_key] = path_match.groupdict() or path_match.groups()
                else:
                    environ[group_key] = ()
                return app(environ, start_response)
            meth_mismatch = meth_mismatch or (path_match and not meth_match)

        if meth_mismatch:
            return handle405(environ, start_response)
        if not path.split('/')[-1].count('.') and not path.endswith('/'):
            script = environ['SCRIPT_NAME'] if redirect_with_script_name else ''
            if not script.endswith('/') and not path.startswith('/'):
                script += '/'
            loc = script + path + '/'
            start_response(status_codes.moved_perm, content_headers.redirect_plain(loc))
            return []

        return handle404(environ, start_response)

    def dispatcher_v2(environ, start_response):
        path = environ['PATH_INFO']

        try:
            app, add_environ, group_data  = v2urls.match_request(
                environ['REQUEST_METHOD'].upper(),
                path,
                environ['QUERY_STRING'])
            environ.update(add_environ)
            environ[group_key] = group_data
        except V2URLS.NotFound:
            app = handle404
        except V2URLS.MethodMismatch:
            app = handle405

        if not path.split('/')[-1].count('.') and not path.endswith('/'):
            script = environ['SCRIPT_NAME'] if redirect_with_script_name else ''
            if not script.endswith('/') and not path.startswith('/'):
                script += '/'
            loc = script + path + '/'
            start_response(status_codes.moved_perm, content_headers.redirect_plain(loc))
            return []

        return app(environ, start_response)

    if type(urls[0][1]) == str:
        return dispatcher_v1
    elif type(urls[0][1]) == tuple:
        v2urls = V2URLS(urls)
        return dispatcher_v2
    else:
        raise AssertionError('Malformed urls file')
